/*
 * MIT License
 *
 * Copyright (c) 2024-2025 milkpotatoes
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

#include <cstddef>
#include <fcntl.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#include "runner_utils/exceptions.h"
#include "runner_utils/log.h"
#include "runner_utils/napi_utils.h"
#include "runner_utils/utils.h"

#include "js_utils/js_file.h"

thread_local napi_ref JsFile::constructorRef_ = nullptr;

JsFile::JsFile(FILE *file) : file_(file)
{}

JsFile::~JsFile()
{
    if (!file_)
        return;
    fclose(file_);
}

JsFile *JsFile::New(std::string path, std::string mode)
{
    errno = 0;
    FILE *file = fopen(path.c_str(), mode.c_str());
    if (!file)
        return nullptr;
    auto that = new JsFile(file);
    if (mode.find("b") != std::string::npos)
        that->isBinary_ = true;
    return that;
}

JsFile *JsFile::New(int fd, std::string mode)
{
    errno = 0;
    FILE *file = fdopen(dup(fd), mode.c_str());
    if (!file)
        return nullptr;
    auto that = new JsFile(file);
    if (mode.find("b") != std::string::npos)
        that->isBinary_ = true;
    return that;
}

struct stat JsFile::GetStat()
{
    struct stat buf;
    fstat(fileno(file_), &buf);
    return buf;
}

int JsFile::GetFlags()
{
    int fd = fileno(file_);
    return fcntl(fd, F_GETFL);
}

napi_value JsFile::Constructor(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_CONSTRUCT "Failed to construct 'File': "
    utils::NapiCallbackInfo info(env, info_);
    if (info.GetArgc() < 1)
    {
        std::string msg = FAILED_TO_CONSTRUCT "1 argument required, but only 0 present.";
        utils::ThrowInvalidArg(env, msg.c_str());
        return nullptr;
    }

    napi_value jsPath = info[0];
    std::string path;
    int fd = -1;
    switch (utils::TypeOf(env, jsPath))
    {
    case napi_string:
        path = utils::GetValueStringUtf8(env, jsPath);
        break;
    case napi_number:
        fd = utils::GetValueInt32(env, jsPath);
        break;
    default:
        std::string message = std::string("[") + exceptions::code::INVALID_ARG_TYPE + "]: " + FAILED_TO_CONSTRUCT +
                              "1st arg must be type of " + "string or number but " +
                              utils::ReadNapiValuetype(utils::TypeOf(env, jsPath)) + " is provided.";
        napi_throw_type_error(env, exceptions::code::INVALID_ARG_TYPE, message.c_str());
        return nullptr;
    }

    std::string mode = "r";
    if (info.GetArgc() >= 2)
    {
        napi_value jsMode = info[1];
        if (!utils::ThrowIfTypeError(env, FAILED_TO_CONSTRUCT, "mode", jsMode, napi_string))
            return nullptr;
        mode = utils::GetValueStringUtf8(env, jsMode);
    }

    JsFile *file = fd > -1 ? JsFile::New(fd, mode) : JsFile::New(path, mode);

    if (!file)
    {
        napi_throw_error(env, nullptr, (std::string(FAILED_TO_CONSTRUCT) + strerror(errno)).c_str());
        return nullptr;
    }
    if (S_ISDIR(file->GetStat().st_mode))
    {
        napi_throw_error(env, nullptr, FAILED_TO_CONSTRUCT "path is directory, but file is required");
        delete file;
        return nullptr;
    }
    napi_value thisVar = info.GetThisVar();
    utils::SetProperty(env, thisVar, "path", jsPath, napi_enumerable);
    if (!utils::Wrap(env, thisVar, file, Finalize))
    {
        napi_throw_range_error(env, nullptr, FAILED_TO_CONSTRUCT "failed to wrap with object");
        file->Delete();
        return nullptr;
    }
    return info.GetThisVar();
#undef FAILED_TO_CONSTRUCT
}

JsFile *JsFile::Unwrap(napi_env env, napi_value object, const char *msg)
{
    JsFile *that = reinterpret_cast<JsFile *>(utils::Unwrap(env, object));

    if (!that)
    {
        std::string errMsg(msg);
        errMsg += ": cannot unwrap native data.";
        utils::ThrowInvalidArg(env, errMsg.c_str());
        return nullptr;
    }

    if (!that->file_)
    {
        std::string errMsg(msg);
        errMsg += ": file is already closed.";
        utils::ThrowInvalidArg(env, errMsg.c_str());
        return nullptr;
    }
    return that;
}

bool JsFile::ThrowIfNotInstanceOf(napi_env env, napi_value object, const char *msg)
{
    napi_value constructor = nullptr;
    napi_get_reference_value(env, constructorRef_, &constructor);
    if (!constructor && !utils::InstanceOf(env, object, constructor))
    {
        napi_throw_type_error(env, exceptions::code::ILLEGAL_INVOCATION, msg);
        return true;
    }
    return false;
}

napi_value JsFile::Close(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to execute close"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();

    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;
    return utils::CreateInt32(env, that->Close());
#undef FAILED_TO_EXECUTE
}
napi_value JsFile::GetFd(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to get fd"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();

    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;

    return utils::CreateInt64(env, fileno(that->file_));
#undef FAILED_TO_EXECUTE
}

napi_value JsFile::GetLength(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to get length"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();

    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;

    struct stat buf = that->GetStat();
    return utils::CreateInt64(env, buf.st_size);
#undef FAILED_TO_EXECUTE
}

DEF_NAPI_CALLBACK(JsFile::GetPath, info)
{
    return utils::GetProperty(info.GetEnv(), info.GetThisVar(), "path");
}

napi_value JsFile::Read(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to execute read"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();
    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    int64_t size = -1;
    if (info.GetArgc() >= 1)
    {
        if (!utils::ThrowIfTypeError(env, FAILED_TO_EXECUTE, "length", info[0], napi_number))
            return nullptr;
        size = utils::GetValueInt64(env, info[0]);
        if (size < 0)
            utils::ThrowInvalidArg(env, FAILED_TO_EXECUTE ": argument length must greater than 0.");
    }

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if ((that->GetFlags() & O_ACCMODE) == O_WRONLY)
    {
        napi_throw_error(env, nullptr, FAILED_TO_EXECUTE ": write not allowed on a write-only file.");
        return nullptr;
    }
    if (!that)
        return nullptr;

    size_t curPos = ftell(that->file_);
    int64_t maxSize = static_cast<int64_t>(that->GetStat().st_size - curPos);
    if (maxSize < 0)
        maxSize = 0;
    if (size < 0 || size > maxSize)
        size = maxSize;

    napi_value result = nullptr;
    if (that->isBinary_)
    {
        void *data = nullptr;
        napi_create_arraybuffer(env, size, &data, &result);
        if (size == 0)
            return result;
        if (!data)
        {
            napi_throw_range_error(env, FAILED_TO_EXECUTE, FAILED_TO_EXECUTE ": failed to create ArrayBuffer");
            return nullptr;
        }
        fread(data, sizeof(uint8_t), size, that->file_);
    }
    else
    {
        char *data = new char[size];
        size_t readSize = fread(data, sizeof(char), size, that->file_);
        result = utils::CreateStringUtf8(env, data, readSize);
        delete[] data;
    }
    return result;
#undef FAILED_TO_EXECUTE
}

napi_value JsFile::Write(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to execute write"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();
    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    if (info.GetArgc() < 1)
    {
        utils::ThrowInvalidArg(env, FAILED_TO_EXECUTE ": 1 argument required, but only 0 present.");
        return nullptr;
    }

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;
    if ((that->GetFlags() & O_ACCMODE) == O_RDONLY)
    {
        napi_throw_error(env, nullptr, FAILED_TO_EXECUTE ": write not allowed on a read-only file.");
        return nullptr;
    }
    utils::StackCaller caller([env, that] {
        if (fflush(that->file_) != 0)
            napi_throw_error(env, nullptr, FAILED_TO_EXECUTE ": fflush failed");
    });
    napi_value jsData = info[0];
    if (that->isBinary_)
    {
        if (!utils::IsArrayBuffer(env, info[0]))
        {
            napi_throw_type_error(env, exceptions::code::INVALID_ARG_TYPE,
                                  (std::string("[") + exceptions::code::INVALID_ARG_TYPE +
                                   "]: " FAILED_TO_EXECUTE ": argument data must be instance of ArrayBuffer.")
                                      .c_str());
            return nullptr;
        }
        void *rawData = nullptr;
        size_t size = 0;
        napi_get_arraybuffer_info(env, jsData, &rawData, &size);
        if (size == 0)
            return nullptr;
        return utils::CreateUint32(env, fwrite(rawData, sizeof(uint8_t), size, that->file_));
    }
    else
    {
        if (utils::IsString(env, jsData))
        {
            std::string rawData = utils::GetValueStringUtf8(env, jsData);
            return utils::CreateUint32(env, fwrite(rawData.c_str(), sizeof(char), rawData.size(), that->file_));
        }
        if (utils::IsArrayBuffer(env, jsData))
        {
            void *rawData = nullptr;
            size_t size = 0;
            napi_get_arraybuffer_info(env, jsData, &rawData, &size);
            if (size == 0)
                return nullptr;
            return utils::CreateUint32(env, fwrite(rawData, sizeof(uint8_t), size, that->file_));
        }
        napi_throw_type_error(env, exceptions::code::INVALID_ARG_TYPE,
                              (std::string("[") + exceptions::code::INVALID_ARG_TYPE +
                               "]: " FAILED_TO_EXECUTE ": argument data must be type of string "
                               "or instance of ArrayBuffer.")
                                  .c_str());
        return nullptr;
    }
#undef FAILED_TO_EXECUTE
}

napi_value JsFile::Seek(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to execute read"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();
    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    if (info.GetArgc() < 1)
    {
        utils::ThrowInvalidArg(env, FAILED_TO_EXECUTE ": 1 argument required, but only 0 present.");
        return nullptr;
    }
    if (!utils::ThrowIfTypeError(env, FAILED_TO_EXECUTE, "offset", info[0], napi_number))
        return nullptr;

    int32_t mode = 1;
    if (info.GetArgc() > 1)
    {
        if (!utils::ThrowIfTypeError(env, FAILED_TO_EXECUTE, "whence", info[1], napi_number))
            return nullptr;
        mode = utils::GetValueInt32(env, info[1]);
    }

    if (mode < SEEK_SET || mode > SEEK_END)
        utils::ThrowInvalidArg(env, FAILED_TO_EXECUTE ": argument whence is invalid");

    int64_t offset = utils::GetValueInt64(env, info[0]);

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;
    errno = 0;
    if (fseek(that->file_, offset, mode) != 0)
    {
        napi_throw_error(env, FAILED_TO_EXECUTE, strerror(errno));
    }
    return nullptr;
#undef FAILED_TO_EXECUTE
}

napi_value JsFile::Tell(napi_env env, napi_callback_info info_)
{
#define FAILED_TO_EXECUTE "Failed to execute close"
    utils::NapiCallbackInfo info(env, info_);
    napi_value thisVar = info.GetThisVar();

    if (ThrowIfNotInstanceOf(env, thisVar, FAILED_TO_EXECUTE))
        return nullptr;

    JsFile *that = Unwrap(env, thisVar, FAILED_TO_EXECUTE);
    if (!that)
        return nullptr;
    return utils::CreateInt64(env, ftell(that->file_));
#undef FAILED_TO_EXECUTE
}

napi_value JsFile::RealPath(napi_env env, napi_callback_info info_)
{
    utils::NapiCallbackInfo info(env, info_);
    if (!utils::ThrowIfTypeError(env, "Failed to execute realpath", "path", info[0], napi_string))
        return nullptr;

    const std::string input = utils::GetValueStringUtf8(env, info[0]);
    std::string output = utils::GetRealPath(input.c_str());
    if (!output.empty())
    {
        return utils::CreateStringUtf8(env, output.c_str());
    };
    return nullptr;
}

void JsFile::Init(napi_env env, napi_value global)
{
    std::vector<napi_property_descriptor> desc{
        {"fd", nullptr, nullptr, GetFd, nullptr, nullptr, napi_default_jsproperty, nullptr},
        {"length", nullptr, nullptr, GetLength, nullptr, nullptr, napi_default_jsproperty, nullptr},
        {"close", nullptr, Close, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"read", nullptr, Read, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"write", nullptr, Write, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"seek", nullptr, Seek, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"tell", nullptr, Tell, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"toString", nullptr, GetPath, nullptr, nullptr, nullptr, napi_default_method, nullptr},
        {"realPath", nullptr, RealPath, nullptr, nullptr, nullptr, napi_static, nullptr},
        {"SEEK_SET", nullptr, nullptr, nullptr, nullptr, utils::CreateInt32(env, SEEK_SET), napi_static, nullptr},
        {"SEEK_CUR", nullptr, nullptr, nullptr, nullptr, utils::CreateInt32(env, SEEK_CUR), napi_static, nullptr},
        {"SEEK_END", nullptr, nullptr, nullptr, nullptr, utils::CreateInt32(env, SEEK_END), napi_static, nullptr},
    };
    napi_value constructor = nullptr;
    napi_define_class(env, "File", NAPI_AUTO_LENGTH, Constructor, nullptr, desc.size(), desc.data(), &constructor);
    napi_create_reference(env, constructor, 1, &constructorRef_);
    utils::Wrap(env, constructor, reinterpret_cast<void *>(JsFile::Init), [](napi_env env, void *, void *) {
        napi_delete_reference(env, constructorRef_);
        constructorRef_ = nullptr;
    });
    utils::SetUnenumerableProperty(env, global, "File", constructor);
}